diff --git a/src/App.tsx b/src/App.tsx index 86cf28d..b9cbc2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,40 +1,16 @@ -import { Outlet, useLocation } from "react-router-dom"; +import { Outlet } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import GlobalLayout from "./components/layout/GlobalLayout"; -function App() { - const location = useLocation(); - - const layoutGroups = { - full: ["/", "/board", "/explore", "/my"], // header + bottom nav - headerOnly: ["/developer", "/community", "/onboarding"], // only header - none: ["/login", "/search"], // no header and bottom nav - }; - - const currentPath = location.pathname; - - // no header and bottom nav - if (layoutGroups.none.some((path) => currentPath.startsWith(path))) { - return ( - - - - ); - } +const queryClient = new QueryClient(); - // only header - if (layoutGroups.headerOnly.some((path) => currentPath.startsWith(path))) { - return ( - +function App() { + return ( + + - ); - } - - // basic(header + bommon nav) - return ( - - - + ); } diff --git a/src/components/common/ModalWrapper.tsx b/src/components/common/ModalWrapper.tsx new file mode 100644 index 0000000..9ffda00 --- /dev/null +++ b/src/components/common/ModalWrapper.tsx @@ -0,0 +1,36 @@ +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ReactNode } from "react"; + +interface ModalWrapperProps { + title: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; +} + +export const ModalWrapper = ({ + title, + onClose, + children, + footer, +}: ModalWrapperProps) => { + return ( +
+
+
+

{title}

+ +
+
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; diff --git a/src/components/common/PositionSelector.tsx b/src/components/common/PositionSelector.tsx new file mode 100644 index 0000000..262e754 --- /dev/null +++ b/src/components/common/PositionSelector.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { fetchPositions } from "@/lib/api"; +import type { PositionCategory } from "@/types/filter"; + +interface PositionSelectorProps { + selectedPosition: number | null; + onPositionChange: (positionId: number) => void; + label?: string; +} + +export const PositionSelector = ({ + selectedPosition, + onPositionChange, + label = "포지션", +}: PositionSelectorProps) => { + const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadPositions = async () => { + try { + const fetchedPositions = await fetchPositions(); + setPositions(fetchedPositions); + } catch (error) { + console.error("포지션 로딩 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadPositions(); + }, []); + + if (isLoading) { + return
로딩 중...
; + } + + return ( +
+ + + {positions.map((cat) => ( + + + {cat.category} + + +
+ {cat.positions.map((pos) => ( + + ))} +
+
+
+ ))} +
+
+ ); +}; diff --git a/src/components/common/StackSelector.tsx b/src/components/common/StackSelector.tsx new file mode 100644 index 0000000..56382ba --- /dev/null +++ b/src/components/common/StackSelector.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Check, ChevronsUpDown, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { fetchStacks } from "@/lib/api"; +import type { Stack } from "@/types/filter"; + +interface StackSelectorProps { + selectedStackIds: number[]; + onStackChange: (stackIds: number[]) => void; + label?: string; + maxSelection?: number; +} + +export const StackSelector = ({ + selectedStackIds, + onStackChange, + label = "기술 스택 / 분야", + maxSelection, +}: StackSelectorProps) => { + const [stacks, setStacks] = useState([]); + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadStacks = async () => { + try { + const fetchedStacks = await fetchStacks(); + setStacks(fetchedStacks); + } catch (error) { + console.error("스택 로딩 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadStacks(); + }, []); + + const handleSelectStack = (stackId: number) => { + const newStackIds = selectedStackIds.includes(stackId) + ? selectedStackIds.filter((id) => id !== stackId) + : maxSelection && selectedStackIds.length >= maxSelection + ? selectedStackIds + : [...selectedStackIds, stackId]; + + onStackChange(newStackIds); + setOpen(false); + }; + + const handleRemoveStack = (stackIdToRemove: number) => { + const newStackIds = selectedStackIds.filter((id) => id !== stackIdToRemove); + onStackChange(newStackIds); + }; + + if (isLoading) { + return
로딩 중...
; + } + + return ( +
+ + + + + + + + + + 검색 결과가 없습니다. + + + {stacks.map((stack) => ( + handleSelectStack(stack.id)} + className="aria-selected:bg-brand-primary text-white"> + + {stack.label} + + ))} + + + + +
+ {selectedStackIds.length > 0 ? ( + selectedStackIds.map((stackId) => { + const stack = stacks.find((s) => s.id === stackId); + return ( + + {stack?.label} + + + ); + }) + ) : ( + 기술 스택을 선택해주세요 + )} +
+
+ ); +}; diff --git a/src/components/layout/BottomNavigation.tsx b/src/components/layout/BottomNavigation.tsx index 8d9b8ac..f6f64e5 100644 --- a/src/components/layout/BottomNavigation.tsx +++ b/src/components/layout/BottomNavigation.tsx @@ -1,19 +1,22 @@ -import { NAV_ITEMS } from '@/constants/navigation'; -import { Button } from '../ui/button'; +import { NAV_ITEMS } from "@/constants/navigation"; +import { Button } from "../ui/button"; +import { useLocation, useNavigate } from "react-router-dom"; export default function BottomNavigation() { + const location = useLocation(); + const navigate = useNavigate(); + return ( ); -} \ No newline at end of file +} diff --git a/src/components/layout/GlobalLayout.tsx b/src/components/layout/GlobalLayout.tsx index 5c60167..b4cc8bf 100644 --- a/src/components/layout/GlobalLayout.tsx +++ b/src/components/layout/GlobalLayout.tsx @@ -1,31 +1,28 @@ import type { ReactNode } from "react"; -import Header from "./Header"; import BottomNavigation from "./BottomNavigation"; +import { useLocation } from "react-router-dom"; interface GlobalLayoutProps { children: ReactNode; - showHeader?: boolean; - showBottomNavigation?: boolean; - headerTitle?: string; } -function GlobalLayout({ - children, - showHeader = true, - showBottomNavigation = true, - headerTitle -}: GlobalLayoutProps) { +function GlobalLayout({ children }: GlobalLayoutProps) { + const location = useLocation(); + + const shouldShowBottomNav = ["/board", "/explore", "/chat", "/my"].some( + (path) => location.pathname.startsWith(path) + ); + return (
-
- {showHeader &&
} -
+
+
{children}
- {showBottomNavigation && } + {shouldShowBottomNav && }
-
+
); -}; +} -export default GlobalLayout; \ No newline at end of file +export default GlobalLayout; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 02d2955..963f3c4 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,40 +1,52 @@ -import { ArrowLeft, Search } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from "@/components/ui/button"; interface HeaderProps { title?: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + onLeftClick?: () => void; + onRightClick?: () => void; } -export default function Header({ title = "COMEET" }: HeaderProps) { - const iconButtonClass = "mx-2 text-white hover:bg-brand-surface hover:text-brand-primary cursor-pointer"; +export default function Header({ + title = "COMEET", + leftIcon, + rightIcon, + onLeftClick, + onRightClick, +}: HeaderProps) { + const iconButtonClass = + "mx-2 text-white hover:bg-brand-surface hover:text-brand-primary cursor-pointer"; return ( -
-
- +
+
+ {leftIcon && ( + + )}
-
-

{title}

+
+

{title}

-
- +
+ {rightIcon && ( + + )}
); -} \ No newline at end of file +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a2df8dc..ddacc0d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -14,7 +14,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: @@ -33,7 +33,7 @@ const buttonVariants = cva( size: "default", }, } -) +); function Button({ className, @@ -43,9 +43,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 275381c..91411d0 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Separator({ className, @@ -22,7 +22,7 @@ function Separator({ )} {...props} /> - ) + ); } -export { Separator } +export { Separator }; diff --git a/src/constants/.gitkeep b/src/constants/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts index 734d4da..2577791 100644 --- a/src/constants/navigation.ts +++ b/src/constants/navigation.ts @@ -1,4 +1,10 @@ -import { Home, MapPin, MessageCircle, User, type LucideIcon } from 'lucide-react'; +import { + Home, + MapPin, + MessageCircle, + User, + type LucideIcon, +} from "lucide-react"; interface NavItem { id: string; @@ -8,23 +14,23 @@ interface NavItem { export const NAV_ITEMS: NavItem[] = [ { - id: 'home', + id: "home", icon: Home, - path: '/' + path: "/board", }, { - id: 'nearby', + id: "nearby", icon: MapPin, - path: '/nearby' + path: "/explore", }, { - id: 'chat', + id: "chat", icon: MessageCircle, - path: '/chat' + path: "/chat", }, { - id: 'profile', + id: "profile", icon: User, - path: '/profile' - } -]; \ No newline at end of file + path: "/my", + }, +]; diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/useAddPostModal.ts b/src/hooks/useAddPostModal.ts new file mode 100644 index 0000000..fecc389 --- /dev/null +++ b/src/hooks/useAddPostModal.ts @@ -0,0 +1,91 @@ +import { useState, useCallback } from "react"; + +interface AddPostModalState { + title: string; + content: string; + selectedBoard: string; + description: string; + recruitCount: number; + position: number | null; + selectedStackIds: number[]; +} + +interface AddPostModalActions { + setTitle: (title: string) => void; + setContent: (content: string) => void; + setSelectedBoard: (board: string) => void; + setDescription: (description: string) => void; + setRecruitCount: (count: number) => void; + setPosition: (position: number | null) => void; + setSelectedStackIds: (stackIds: number[]) => void; + reset: () => void; +} + +export const useAddPostModal = (): AddPostModalState & + AddPostModalActions & { isRecruitBoard: boolean } => { + const [state, setState] = useState({ + title: "", + content: "", + selectedBoard: "1", + description: "", + recruitCount: 1, + position: null, + selectedStackIds: [], + }); + + const setTitle = useCallback((title: string) => { + setState((prev) => ({ ...prev, title })); + }, []); + + const setContent = useCallback((content: string) => { + setState((prev) => ({ ...prev, content })); + }, []); + + const setSelectedBoard = useCallback((selectedBoard: string) => { + setState((prev) => ({ ...prev, selectedBoard })); + }, []); + + const setDescription = useCallback((description: string) => { + setState((prev) => ({ ...prev, description })); + }, []); + + const setRecruitCount = useCallback((recruitCount: number) => { + setState((prev) => ({ ...prev, recruitCount })); + }, []); + + const setPosition = useCallback((position: number | null) => { + setState((prev) => ({ ...prev, position })); + }, []); + + const setSelectedStackIds = useCallback((selectedStackIds: number[]) => { + setState((prev) => ({ ...prev, selectedStackIds })); + }, []); + + const reset = useCallback(() => { + setState({ + title: "", + content: "", + selectedBoard: "1", + description: "", + recruitCount: 1, + position: null, + selectedStackIds: [], + }); + }, []); + + const isRecruitBoard = + state.selectedBoard === "3" || state.selectedBoard === "4"; + + return { + ...state, + setTitle, + setContent, + setSelectedBoard, + setDescription, + setRecruitCount, + setPosition, + setSelectedStackIds, + reset, + isRecruitBoard, + }; +}; diff --git a/src/hooks/usePost.ts b/src/hooks/usePost.ts index a6a9489..5dcef44 100644 --- a/src/hooks/usePost.ts +++ b/src/hooks/usePost.ts @@ -1,35 +1,29 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import type { Post, Comment } from '@/types/community'; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Post, Comment } from "@/types/community"; const fetchPost = async (postId: string): Promise => { const res = await fetch(`/api/posts/${postId}`); - if (!res.ok) throw new Error('게시글을 불러오는데 실패했습니다.'); + if (!res.ok) throw new Error("게시글을 불러오는데 실패했습니다."); return res.json(); }; const fetchComments = async (postId: string): Promise => { const res = await fetch(`/api/posts/${postId}/comments`); - if (!res.ok) throw new Error('댓글을 불러오는데 실패했습니다.'); + if (!res.ok) throw new Error("댓글을 불러오는데 실패했습니다."); return res.json(); }; export function usePost(postId: string) { return useQuery({ - queryKey: ['post', postId], - queryFn: async () => { - const [postData, commentsData] = await Promise.all([ - fetchPost(postId), - fetchComments(postId), - ]); - return { ...postData, comments: commentsData }; - }, + queryKey: ["post", postId], + queryFn: () => fetchPost(postId), enabled: !!postId, }); } const togglePostLike = async (postId: string) => { - const res = await fetch(`/api/posts/${postId}/like`, { method: 'PUT' }); - if (!res.ok) throw new Error('좋아요 클릭에 실패했습니다.'); + const res = await fetch(`/api/posts/${postId}/like`, { method: "PUT" }); + if (!res.ok) throw new Error("좋아요 클릭에 실패했습니다."); return res.json(); }; @@ -38,18 +32,24 @@ export function useTogglePostLike() { return useMutation({ mutationFn: togglePostLike, onSuccess: (_data, postId) => { - queryClient.invalidateQueries({ queryKey: ['post', postId] }); + queryClient.invalidateQueries({ queryKey: ["post", postId] }); }, }); } -const addComment = async ({ postId, content }: { postId: string, content: string }) => { +const addComment = async ({ + postId, + content, +}: { + postId: string; + content: string; +}) => { const res = await fetch(`/api/posts/${postId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); - if (!res.ok) throw new Error('댓글 작성에 실패했습니다.'); + if (!res.ok) throw new Error("댓글 작성에 실패했습니다."); return res.json(); }; @@ -58,7 +58,39 @@ export function useAddComment() { return useMutation({ mutationFn: addComment, onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['post', variables.postId] }); + queryClient.invalidateQueries({ queryKey: ["post", variables.postId] }); + }, + }); +} + +const toggleCommentLike = async (commentId: number) => { + const res = await fetch(`/api/comments/${commentId}/like`, { method: "PUT" }); + if (!res.ok) throw new Error("댓글 좋아요 클릭에 실패했습니다."); + return res.json(); +}; + +export function useToggleCommentLike() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: toggleCommentLike, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["post"] }); }, }); -} \ No newline at end of file +} + +const toggleReplyLike = async (replyId: number) => { + const res = await fetch(`/api/replies/${replyId}/like`, { method: "PUT" }); + if (!res.ok) throw new Error("답글 좋아요 클릭에 실패했습니다."); + return res.json(); +}; + +export function useToggleReplyLike() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: toggleReplyLike, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["post"] }); + }, + }); +} diff --git a/src/index.css b/src/index.css index 964cecb..aa7641f 100644 --- a/src/index.css +++ b/src/index.css @@ -99,7 +99,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); + --border: oklch(0.9042 0.2304 134.57); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); @@ -125,4 +125,14 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} + +/* 스크롤바 숨김 */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} diff --git a/src/main.tsx b/src/main.tsx index 3c9cf3f..ee0351e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,8 @@ import { DeveloperPage } from "./pages/developer/index.tsx"; import { ExplorePage } from "./pages/explore/index.tsx"; import { OnboardingPage } from "./pages/onboarding/OnboardingPage.tsx"; import { PostDetailPage } from "./pages/community/PostDetailPage.tsx"; +import ChatPage from "./pages/chat/index.tsx"; +import SearchPage from "./pages/search/index.tsx"; async function enableMocking() { if (import.meta.env.DEV) { @@ -27,6 +29,10 @@ const router = createBrowserRouter([ index: true, element: , }, + { + path: "board", + element: , + }, { path: "board/:category", element: , @@ -35,6 +41,10 @@ const router = createBrowserRouter([ path: "explore", element: , }, + { + path: "chat", + element: , + }, { path: "my", element: , @@ -44,7 +54,7 @@ const router = createBrowserRouter([ element: , }, { - path: "community", + path: "community/:postId", element: , }, { @@ -55,6 +65,10 @@ const router = createBrowserRouter([ path: "onboarding", element: , }, + { + path: "search", + element: , + }, ], }, ]); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index e5ac425..a4e6393 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -3,6 +3,7 @@ import type { Developer } from "@/types/developer"; import type { Post } from "@/types/board"; import type { ExploreTabValue } from "@/constants/explore"; import type { UICategory } from "@/constants/board"; +import type { Post as CommunityPost, Comment, Reply } from "@/types/community"; const FAKE_DEVELOPERS: Developer[] = [ { @@ -158,6 +159,271 @@ const ALL_STACKS = [ { id: 18, label: "Git" }, ]; +const POST_DETAIL_DATA: CommunityPost[] = [ + { + id: 1, + category: "자유", + title: "오늘 날씨 좋네요! 다들 뭐하시나요?", + author: { + name: "하늘구경", + avatarUrl: "", + }, + createdAt: "2025.07.29", + content: + "오늘 날씨가 정말 좋네요! 다들 뭐하고 계신가요? 저는 오늘 공원에서 산책하고 왔어요. 코딩도 하고 운동도 하고, 정말 좋은 하루였습니다. 여러분도 좋은 하루 보내세요!", + likeCount: 12, + isLiked: false, + comments: [ + { + id: 1, + author: { + name: "코딩러버", + avatarUrl: "", + }, + content: + "저도 오늘 날씨 좋았어요! 집에서 코딩하고 있었는데, 창밖을 보니 정말 맑네요.", + createdAt: "2025.07.29 14:30", + likeCount: 3, + isLiked: false, + replyCount: 2, + replies: [ + { + id: 101, + author: { + name: "날씨좋아요", + avatarUrl: "", + }, + content: + "저도 오늘 정말 날씨가 좋았어요! 창밖을 보니 하늘이 너무 맑네요.", + createdAt: "2025.07.29 15:00", + likeCount: 1, + isLiked: false, + }, + { + id: 102, + author: { + name: "산책러버", + avatarUrl: "", + }, + content: + "저도 공원에서 산책했어요! 코딩하다가 잠깐 나가니까 정말 좋았어요.", + createdAt: "2025.07.29 15:30", + likeCount: 2, + isLiked: true, + }, + ], + }, + { + id: 2, + author: { + name: "개발자킹", + avatarUrl: "", + }, + content: + "저는 오늘 새로운 프로젝트를 시작했어요. React와 TypeScript로 뭔가 만들어보려고 해요.", + createdAt: "2025.07.29 15:15", + likeCount: 5, + isLiked: true, + replyCount: 0, + }, + ], + }, + { + id: 2, + category: "질문", + title: "리액트 Hook 질문 있습니다. useEffect 종속성 배열 관련...", + author: { + name: "궁금해요", + avatarUrl: "", + }, + createdAt: "2025.07.28", + content: + "안녕하세요! React useEffect의 종속성 배열에 대해 질문이 있습니다. 빈 배열 []을 넣으면 컴포넌트가 마운트될 때만 실행되는 건 알겠는데, 의존성 배열에 함수를 넣으면 어떻게 되나요? 예를 들어 [fetchData] 같은 식으로요. 이렇게 하면 fetchData 함수가 변경될 때마다 useEffect가 실행되는 건가요?", + likeCount: 3, + isLiked: true, + comments: [ + { + id: 3, + author: { + name: "React마스터", + avatarUrl: "", + }, + content: + "네, 맞습니다! 의존성 배열에 함수를 넣으면 그 함수가 변경될 때마다 useEffect가 실행됩니다. 그래서 보통 useCallback을 사용해서 함수를 메모이제이션하거나, 함수를 useEffect 내부로 이동시키는 방법을 사용해요.", + createdAt: "2025.07.28 16:20", + likeCount: 8, + isLiked: false, + replyCount: 1, + replies: [ + { + id: 201, + author: { + name: "React초보", + avatarUrl: "", + }, + content: + "정말 도움이 되는 답변 감사합니다! useCallback에 대해 더 자세히 알고 싶어요.", + createdAt: "2025.07.28 17:00", + likeCount: 3, + isLiked: false, + }, + ], + }, + ], + }, + { + id: 3, + category: "프로젝트", + title: "사이드 프로젝트 팀원 구합니다! (프론트 1, 백엔드 1)", + author: { + name: "열정맨", + avatarUrl: "", + }, + createdAt: "2025.07.27", + content: + "안녕하세요! 현재 사이드 프로젝트를 진행하고 있는데, 팀원을 구하고 있습니다. 프로젝트는 React + Node.js로 개발할 예정이고, 프론트엔드 개발자 1명, 백엔드 개발자 1명을 찾고 있어요. 주 2-3회 온라인 미팅 예정이고, 3개월 정도 진행할 계획입니다. 관심 있으신 분은 댓글로 연락처 남겨주세요!", + likeCount: 25, + isLiked: false, + comments: [ + { + id: 4, + author: { + name: "프론트엔드러버", + avatarUrl: "", + }, + content: + "안녕하세요! 프론트엔드 개발자입니다. React 경험 2년 있고, TypeScript도 사용 가능해요. 어떤 프로젝트인지 더 자세히 알 수 있을까요?", + createdAt: "2025.07.27 18:30", + likeCount: 12, + isLiked: true, + replyCount: 3, + replies: [ + { + id: 301, + author: { + name: "열정맨", + avatarUrl: "", + }, + content: + "안녕하세요! 프로젝트는 웹 기반 소셜 플랫폼입니다. React + Node.js로 개발할 예정이에요.", + createdAt: "2025.07.27 19:00", + likeCount: 5, + isLiked: true, + }, + { + id: 302, + author: { + name: "프론트엔드러버", + avatarUrl: "", + }, + content: "좋아요! 어떤 기능들이 포함될 예정인가요?", + createdAt: "2025.07.27 19:15", + likeCount: 2, + isLiked: false, + }, + { + id: 303, + author: { + name: "열정맨", + avatarUrl: "", + }, + content: + "사용자 인증, 게시글 작성, 댓글, 좋아요 기능 등이 포함될 예정입니다!", + createdAt: "2025.07.27 19:30", + likeCount: 4, + isLiked: false, + }, + ], + }, + { + id: 5, + author: { + name: "백엔드개발자", + avatarUrl: "", + }, + content: + "백엔드 개발자입니다! Node.js, Express 경험 있고, MongoDB도 사용 가능해요. 프로젝트 구체적인 내용 궁금해요.", + createdAt: "2025.07.27 19:15", + likeCount: 7, + isLiked: false, + replyCount: 0, + }, + ], + }, + { + id: 4, + category: "모각코", + title: "이번 주말에 강남에서 같이 코딩하실 분?", + author: { + name: "코딩친구", + avatarUrl: "", + }, + createdAt: "2025.07.26", + content: + "안녕하세요! 이번 주말에 강남역 근처 카페에서 같이 코딩하실 분 구해요. 각자 프로젝트 하면서 서로 피드백 주고받는 식으로 진행하려고 해요. 시간은 토요일 오후 2시부터 6시까지 예정입니다. 관심 있으신 분 댓글로 남겨주세요!", + likeCount: 8, + isLiked: false, + comments: [ + { + id: 6, + author: { + name: "주말코딩러", + avatarUrl: "", + }, + content: + "안녕하세요! 저도 참여하고 싶어요. 강남역 어느 카페에서 진행하실 건가요?", + createdAt: "2025.07.26 20:45", + likeCount: 3, + isLiked: true, + replyCount: 1, + replies: [ + { + id: 401, + author: { + name: "코딩친구", + avatarUrl: "", + }, + content: + "강남역 2번 출구 근처 스타벅스에서 진행할 예정입니다! 시간은 오후 2시부터 6시까지예요.", + createdAt: "2025.07.26 21:00", + likeCount: 2, + isLiked: false, + }, + ], + }, + ], + }, + { + id: 5, + category: "자유", + title: "다들 점심 뭐 드셨나요? 메뉴 추천 받습니다.", + author: { + name: "배고파요", + avatarUrl: "", + }, + createdAt: "2025.07.29", + content: + "오늘 점심 메뉴 고민 중이에요. 다들 뭐 드셨나요? 개발자들 사이에서 인기 있는 점심 메뉴나 추천하고 싶은 메뉴 있으시면 알려주세요! 저는 보통 김치찌개나 된장찌개를 자주 먹는데, 오늘은 뭔가 다르게 먹고 싶어요.", + likeCount: 2, + isLiked: false, + comments: [ + { + id: 7, + author: { + name: "맛집탐험가", + avatarUrl: "", + }, + content: + "저는 오늘 회사 근처 새로 생긴 돈까스집에서 먹었어요! 정말 맛있었어요. 개발할 때는 단백질이 풍부한 음식이 좋다고 하더라고요.", + createdAt: "2025.07.29 12:30", + likeCount: 4, + isLiked: false, + replyCount: 0, + }, + ], + }, +]; + export const handlers = [ // 개발자 목록 http.get("/api/developers", ({ request }) => { @@ -181,6 +447,170 @@ export const handlers = [ return HttpResponse.json(filtered); }), + // 개별 게시글 조회 + http.get("/api/posts/:postId", ({ params }) => { + const postId = parseInt(params.postId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json(post); + }), + + // 게시글 댓글 목록 + http.get("/api/posts/:postId/comments", ({ params }) => { + const postId = parseInt(params.postId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json(post.comments); + }), + + // 게시글 좋아요 토글 + http.put("/api/posts/:postId/like", ({ params }) => { + const postId = parseInt(params.postId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + // 좋아요 상태 토글 + post.isLiked = !post.isLiked; + post.likeCount += post.isLiked ? 1 : -1; + + return HttpResponse.json({ success: true }); + }), + + // 댓글 추가 + http.post("/api/posts/:postId/comments", async ({ params, request }) => { + const postId = parseInt(params.postId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + const body = (await request.json()) as { content: string }; + const newComment: Comment = { + id: Date.now(), + author: { + name: "사용자", + avatarUrl: "", + }, + content: body.content, + createdAt: new Date().toLocaleString("ko-KR"), + likeCount: 0, + isLiked: false, + replyCount: 0, + }; + + post.comments.push(newComment); + + return HttpResponse.json(newComment); + }), + + // 댓글 답글 목록 + http.get("/api/posts/:postId/comments/:commentId/replies", ({ params }) => { + const postId = parseInt(params.postId as string, 10); + const commentId = parseInt(params.commentId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + const comment = post.comments.find((c) => c.id === commentId); + if (!comment || !comment.replies) { + return HttpResponse.json([]); + } + + return HttpResponse.json(comment.replies); + }), + + // 답글 추가 + http.post( + "/api/posts/:postId/comments/:commentId/replies", + async ({ params, request }) => { + const postId = parseInt(params.postId as string, 10); + const commentId = parseInt(params.commentId as string, 10); + const post = POST_DETAIL_DATA.find((p) => p.id === postId); + + if (!post) { + return new HttpResponse(null, { status: 404 }); + } + + const comment = post.comments.find((c) => c.id === commentId); + if (!comment) { + return new HttpResponse(null, { status: 404 }); + } + + const body = (await request.json()) as { content: string }; + const newReply: Reply = { + id: Date.now(), + author: { + name: "사용자", + avatarUrl: "", + }, + content: body.content, + createdAt: new Date().toLocaleString("ko-KR"), + likeCount: 0, + isLiked: false, + }; + + if (!comment.replies) { + comment.replies = []; + } + comment.replies.push(newReply); + comment.replyCount = comment.replies.length; + + return HttpResponse.json(newReply); + } + ), + + // 댓글 좋아요 토글 + http.put("/api/comments/:commentId/like", ({ params }) => { + const commentId = parseInt(params.commentId as string, 10); + + // 모든 게시글에서 해당 댓글 찾기 + for (const post of POST_DETAIL_DATA) { + const comment = post.comments.find((c) => c.id === commentId); + if (comment) { + comment.isLiked = !comment.isLiked; + comment.likeCount += comment.isLiked ? 1 : -1; + return HttpResponse.json({ success: true }); + } + } + + return new HttpResponse(null, { status: 404 }); + }), + + // 답글 좋아요 토글 + http.put("/api/replies/:replyId/like", ({ params }) => { + const replyId = parseInt(params.replyId as string, 10); + + // 모든 게시글의 모든 댓글에서 해당 답글 찾기 + for (const post of POST_DETAIL_DATA) { + for (const comment of post.comments) { + if (comment.replies) { + const reply = comment.replies.find((r) => r.id === replyId); + if (reply) { + reply.isLiked = !reply.isLiked; + reply.likeCount += reply.isLiked ? 1 : -1; + return HttpResponse.json({ success: true }); + } + } + } + } + + return new HttpResponse(null, { status: 404 }); + }), + // 포지션 목록 http.get("/api/positions", () => { return HttpResponse.json(POSITION_DATA); diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx new file mode 100644 index 0000000..36bff1f --- /dev/null +++ b/src/pages/chat/index.tsx @@ -0,0 +1,7 @@ +export default function ChatPage() { + return ( +
+

채팅

+
+ ); +} diff --git a/src/pages/community/PostDetailPage.tsx b/src/pages/community/PostDetailPage.tsx index 2225f51..d940d59 100644 --- a/src/pages/community/PostDetailPage.tsx +++ b/src/pages/community/PostDetailPage.tsx @@ -1,12 +1,20 @@ -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { PostContent } from "./__components/PostContent"; import { CommentSection } from "./__components/CommentSection"; import { CommentForm } from "./__components/CommentForm"; -import GlobalLayout from "@/components/layout/GlobalLayout"; -import { usePost, useTogglePostLike, useAddComment } from "@/hooks/usePost"; +import Header from "@/components/layout/Header"; +import { ArrowLeft, Bookmark } from "lucide-react"; +import { + usePost, + useTogglePostLike, + useAddComment, + useToggleCommentLike, + useToggleReplyLike, +} from "@/hooks/usePost"; export function PostDetailPage() { const { postId } = useParams<{ postId: string }>(); + const navigate = useNavigate(); if (!postId) { return
유효하지 않은 게시글 ID입니다.
; @@ -15,6 +23,8 @@ export function PostDetailPage() { const { data: post, isLoading, isError, error } = usePost(postId); const { mutate: toggleLike, isPending: isLikePending } = useTogglePostLike(); const { mutate: addNewComment, isPending: isAddingComment } = useAddComment(); + const { mutate: toggleCommentLike } = useToggleCommentLike(); + const { mutate: toggleReplyLike } = useToggleReplyLike(); if (isLoading) { return
로딩 중...
; @@ -26,28 +36,51 @@ export function PostDetailPage() { return
게시글이 없습니다.
; } - const handleToggleCommentLike = (commentId: number) => - console.log("like comment:", commentId); + const handleToggleCommentLike = (commentId: number) => { + toggleCommentLike(commentId); + }; + + const handleToggleReplyLike = (replyId: number) => { + toggleReplyLike(replyId); + }; + + const handleBackClick = () => { + navigate(-1); + }; + + const handleBookmarkClick = () => { + console.log("북마크 클릭"); + }; return ( - -
-
- toggleLike(postId)} - isLikePending={isLikePending} - /> - +
} + rightIcon={} + onLeftClick={handleBackClick} + onRightClick={handleBookmarkClick} + /> +
+
+
+ toggleLike(postId)} + isLikePending={isLikePending} + /> + +
+ addNewComment({ postId, content })} + isPending={isAddingComment} />
- addNewComment({ postId, content })} - isPending={isAddingComment} - />
- +
); } diff --git a/src/pages/community/__components/CommentForm.tsx b/src/pages/community/__components/CommentForm.tsx index 2a81a5e..92c2d48 100644 --- a/src/pages/community/__components/CommentForm.tsx +++ b/src/pages/community/__components/CommentForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; interface CommentFormProps { onSubmit: (content: string) => void; @@ -17,21 +17,19 @@ export function CommentForm({ onSubmit, isPending }: CommentFormProps) { }; return ( -
-